Skip to content

feat(map): support CARTO basemap API keys - #13

Open
dborup wants to merge 3 commits into
masterfrom
codex/carto-basemaps-api-key
Open

feat(map): support CARTO basemap API keys#13
dborup wants to merge 3 commits into
masterfrom
codex/carto-basemaps-api-key

Conversation

@dborup

@dborup dborup commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Problem

Since August 2026 CARTO requires a Basemaps API key on raster tile requests. Keyless requests still return HTTP 200, but the PNG itself is stamped API KEY REQUIRED — carto.com/basemapsapikey. Because carto-dark / carto-light are the built-in defaults, every CoreScope instance that has not changed tile providers renders watermarked maps.

The carto.key contract

"map": { "tiles": { "providers": {
  "carto": { "enabled": true, "domain": "", "key": "" }
}}}

The field is named key to match upstream Kpa-clawbot/CoreScope#1919, so one config.json works on both this fork and upstream. There is no legacy token alias — the field was renamed before this branch was ever deployed, and a test asserts carto.token is inert and cannot come back.

input emitted URL
unset / "" …/dark_all/{z}/{x}/{y}{r}.pngbyte-identical to the pre-key URL
"K1" …{r}.png?key=K1
" K1 " …{r}.png?key=K1 (trimmed)
" " (whitespace only) keyless — no bare ?
"a b/c&d" …?key=a%20b%2Fc%26d — encoded exactly once
domain + key https://{s}.mycompany.cartocdn.com/…?key=K1

Config-ready lifecycle

Resolving the key lazily is not enough on its own: Leaflet starts fetching the moment a layer is added, so a keyless first paint would put watermarked tiles in the browser cache before setUrl() could swap them.

MC_whenTileConfigReady(cb) defers the first attach until /api/config/client has settled — it waits on settle rather than fulfilment (then(run, run)) with a one-shot guard, so config-success and config-failure each produce exactly one attach and no duplicate tile layers or controls. If no config promise exists at all (standalone pages, older builds, unit tests) the callback runs synchronously, preserving the previous ordering. A config-fetch failure still yields a working keyless layer rather than an empty map.

Map and Live use identical semantics.

Every CARTO surface is covered

MC_getCartoTileUrl(path) is the single place a CARTO URL is built. It takes a bare tile path only — a caller cannot smuggle in its own host or querystring — and re-resolves on every call, so config landing later still applies.

surface route
5 registry styles (carto-dark, carto-light, carto-voyager, carto-voyager-dark, positron-dark) map-tile-providers.js
Main map map.jsMC_whenTileConfigReady
Live map live.jsMC_whenTileConfigReady
Node-detail / TILE_DARK / TILE_LIGHT roles.js accessors
Customize geo-filter tab and modal customize-v2.js ×2
Standalone geo-filter builder geofilter-builder.html

A test asserts the only runtime cartocdn occurrence left in public/ is inside _getCartoBase() itself — nothing bypasses the helper.

Note on "enabled": false — it removes CARTO from the registered main-map / layer-picker styles only. The dedicated geo-filter maps call CARTO directly and still need key set to avoid the watermark.

The key reaches the browser

Like the existing OSM and Stamen tokens, this key is served to the client — unavoidable for raster tiles. The example config says, deliberately without asserting what CARTO does or does not offer:

The key is sent to the browser. Follow any domain/referrer restrictions offered when CARTO issues the key, use a key dedicated to this deployment, monitor its usage, and rotate it if abused.

No real key appears anywhere in this diff; the fixtures are the literal placeholder YOUR_CARTO_BASEMAP_KEY and obvious fakes.

domain validation — misconfiguration hardening

domain is concatenated straight into the host, so an unvalidated value escapes it. This is not an authorization boundary — the value comes from the operator's own config.json — but a typo or a pasted full URL should degrade safely rather than silently retarget tile requests, and the key, at another host:

domain "evil.com/x?a=b" + key SECRET
  → https://{s}.evil.com/x?a=b.cartocdn.com/dark_all/…?key=SECRET
  → host a.evil.com

? / # also smuggle a query ahead of the ?key= suffix, producing a second ?.

Accepted: dot-separated DNS labels, trimmed — mycompany, eu.mycompany, my-company, labels up to 63 characters, whole value up to 238 (keeping "a." + domain + ".cartocdn.com" inside the 253-character host limit).

Rejected → falls back to https://{s}.basemaps.cartocdn.com: schemes, /, ?, #, whitespace, empty labels (which covers leading/trailing dots and ..), leading/trailing hyphens, and over-long labels or values.

Blank/unset is normal and warns not at all. An invalid value logs one warning that names only the expected form — it never reprints the operator's value, which can be of unknown provenance and can end up in shared logs or a screenshot.

Tests

test-carto-basemap-key.js74 passed, 0 failed, 5 consecutive clean runs. Coverage: all five styles; missing / empty / whitespace-only key; trimming; URL-encoding and no double-encoding; no key leak into OSM / Esri / Stamen (which keep their own token / api_key spellings); enterprise domain + key composition; querystring smuggling; config before map-init, after map-init, and on fetch failure; automatic layer and layer picker; Map, Live, Customizer and geo-filter builder; exactly one tile attach and one control; every runtime cartocdn occurrence going through the helper; the full domain-validation matrix including the 63/64-character boundary; the warning not echoing input and firing at most once; and static guards that carto.token / _getCartoToken cannot return.

Also green: upstream's own test-issue-1420-tile-providers.js (33/33, unchanged), test-issue-1614-tile-url-function.js, test-issue-1412-customizer-no-override.js, test-geo-filter.js, test-issue-1407-cb-preset-propagation.js. node --check, the builder's inline script, JSON validation, bash -n test-all.sh and git diff --check all clean.

Known baseline failures, unrelated to this branch: test-issue-1470-node-tile-helper.js and test-issue-1438-customizer-mcrole.js fail identically on master. A full frontend sweep over 300 files is otherwise byte-identical to master (157 vs 156 pass — the difference is this PR's new test file — and the same 143 pre-existing failures).

Comparison with upstream Kpa-clawbot#1919

Upstream's merged fix is 3 files and touches only BASE_STYLES. Cases this branch covers that it does not: whitespace-only and untrimmed keys (upstream emits ?key=%20%20), non-string keys, the four CARTO surfaces outside the registry (which upstream leaves watermarked), the deferred first paint, and the domain host-escape — upstream's _getCartoBase is unvalidated in the same way.

Shelf life

CARTO is retiring raster basemaps in favour of vector. This restores the default experience now; a vector migration is separate, larger work that will be needed later.

⚠️ Do not deploy this branch to the current staging server yet

Staging runs corescope:pr12-0d504756, built from PR #12 (privacy notice + Priority+ navigation fixes). This branch is based on master (f88bf97b) and contains none of that work — verified: no public/privacy.js, no currentLinks() nav-lifecycle fix, no .nav-right ResizeObserver re-fit, no DPO validation in cmd/server/config.go.

Deploying PR #13 directly onto the current staging server would produce a regressive tree. Either merge both lines first, or build a combined image, before any staging deploy.

dborup and others added 3 commits September 1, 2026 08:35
From August 2026 CARTO requires a Basemaps API key on raster tile
requests; without one the tiles come back stamped "API KEY REQUIRED --
carto.com/basemapsapikey". CoreScope built its CARTO URLs by hand in
five different places and never sent a key.

Adds map.tiles.providers.carto.token and routes every CARTO surface
through one helper, window.MC_getCartoTileUrl(path), which owns the
base URL (including the enterprise `domain` override), trims the token
and appends ?key=<encodeURIComponent(token)> -- or nothing at all, so a
missing token never leaves a bare "?". It takes a tile path only, never
a full URL, so no caller can smuggle in its own host or querystring.

Surfaces converted: the five registry styles, roles.js' TILE_DARK /
TILE_LIGHT (now accessors that re-resolve on every read, which also
fixes their load-order and async-config problems), both Customize
geo-filter maps, and the standalone geofilter-builder page -- which
previously had no config load of its own and now fetches
/api/config/client before building its layer.

Leaflet requests tiles the moment a layer joins a map, so resolving the
token late was not enough on its own: map.js and live.js now build the
Auto layer and the layer picker inside one idempotent
MC_whenTileConfigReady callback, so the first CARTO request already
carries the key instead of caching a watermarked tile. Map creation,
panes, zoom and fullscreen controls are unaffected. If the config fetch
fails -- or map-tile-providers.js is missing entirely -- everything
falls back to the previous keyless behaviour rather than an empty map.

Backwards compatible: carto.enabled=false still removes CARTO from the
registered styles (the dedicated geo-filter maps keep using it, as the
config comment now spells out), and an install with no token behaves
exactly as before, just watermarked by CARTO.

No real key is committed; config.example.json ships an empty token and
documents the YOUR_CARTO_BASEMAP_KEY placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rden domain

Upstream Kpa-clawbot#1919 merged as 7aa60c0 and named the
config field `tiles.providers.carto.key`; ours was `carto.token`. Since
this branch has never been pushed or deployed there is no migration to
carry, so this is a clean rename to the upstream spelling rather than
permanent dual support - one config now works on both forks.

Also hardens `carto.domain`, which neither implementation validated. It
is concatenated straight into the host, so a bad value escapes it:

    domain "evil.com/x?a=b"
      -> https://{s}.evil.com/x?a=b.cartocdn.com/dark_all/...
      -> host a.evil.com, and with a key set the ?key= suffix is sent
         THERE, so a config value exfiltrates the key

'?' or '#' in the value also smuggle a query ahead of our own suffix and
produce a second '?'. `domain` now accepts dot-separated DNS labels only
(the documented enterprise form, e.g. "mycompany"), is trimmed, and any
other value is ignored with one console warning, falling back to the
public base. Valid domains are unaffected. This applies to upstream too.

Everything else from 8738776 is kept: MC_getCartoTileUrl remains the one
place a CARTO URL is built (verified - the only runtime `cartocdn`
occurrence left in public/ is inside _getCartoBase itself), the
MC_whenTileConfigReady deferral still prevents a keyless first paint on
Map and Live, and roles.js / customize-v2.js / geofilter-builder.html
still resolve through the helper. Key handling is unchanged: trimmed,
whitespace-only treated as absent, encoded exactly once, and an empty or
missing key emits the byte-identical pre-key URL.

Tests: 64 passed (was 55), 5 consecutive clean runs. Nine new cases cover
valid/dotted/trimmed domains, ten host-escaping values that must be
ignored, proof the key never reaches an injected host and that exactly
one querystring is emitted, domain+key composition across all five
styles, and that the legacy `carto.token` field is now inert in code and
absent from the example. The example's claim that the key can be
"restricted by origin/referrer" is corrected - CARTO Basemaps keys cannot
be - and the domain restriction is documented.

Upstream's own suite still passes unchanged (33/33). Full frontend sweep
over 300 files is identical to the 8738776 baseline (157/143; the 143
are pre-existing, and test-issue-1470 / test-issue-1438 fail on both
trees).
Four review findings on top of 2932368.

1. config.example.json asserted "CARTO Basemaps keys cannot be restricted
   by origin or referrer". I could not document that from CARTO's current
   basemap-key interface, and the previous wording asserted the opposite
   just as confidently. Replaced with guidance that holds either way:
   follow any domain/referrer restrictions offered when CARTO issues the
   key, use a key dedicated to this deployment, monitor its usage, and
   rotate it if abused.

2. Finished the token -> key rename in prose. Six comments in
   map-tile-providers.js plus one each in live.js and roles.js still said
   "token" while describing carto.key. OSM and Stamen keep "token" - that
   is their actual field name - and a test now asserts both halves so the
   rename cannot regress or over-reach.

3. The invalid-domain warning echoed the rejected value. That value is
   operator input of unknown provenance and the line can reach shared
   logs or a screenshot, so it now names only the expected form. Still
   exactly one warning per page, still falling back to the public base.

4. The validator accepted over-long labels: the regex had no length rule,
   so a 64-character label - or a 300-character value - built a host that
   is invalid per DNS. Labels are now capped at 63 and the whole value at
   238, which keeps "a." + domain + ".cartocdn.com" inside the
   253-character host limit. Empty labels are rejected explicitly, which
   also covers leading/trailing dots and '..' runs. This is URL and
   misconfiguration hardening, not an authorization boundary: the value
   comes from the operator's own config.json, and the point is that a
   typo or a pasted full URL degrades to the public base instead of
   silently retargeting tiles and the key at another host.

Runtime semantics and the config-ready lifecycle are unchanged:
MC_getCartoTileUrl is still the single URL builder, MC_whenTileConfigReady
still defers the first attach on Map and Live, and key handling (trim,
whitespace-only as absent, encoded once, byte-identical keyless URL) is
untouched. carto.token stays inert.

Tests: 74 passed (was 64), 5 consecutive clean runs. Ten new cases cover
the 63/64-character label boundary, an over-long overall value, empty
labels and leading/trailing dots and hyphens, an internal hyphen still
being valid, the warning never reprinting the rejected value (asserted
with a value carrying a fake secret), at most one warning, no warning at
all for a blank domain, a static guard against carto.token and
_getCartoToken returning to any production file, the example carrying
neither the field nor the wording while OSM keeps its own, and the
absence of any unverifiable claim about key restrictions in either
direction. Against 2932368 exactly these four fail: the 64-character
label, the over-long value, the value-echoing warning and the
"cannot be restricted" claim.

Upstream's suite still passes unchanged (33/33). Full frontend sweep over
300 files identical to the 2932368 baseline (157/143).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant